Repository navigation
fix(metadata-protocol): batchData's by-id upsert chooses its arm by what the caller can read - #28721
Conversation
…hat the caller can read Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…d the HTTP door Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…insert double's note Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
…bership policy Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
… erasing its options Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6f9ad22003ac11f3c7cc4819824d28f5b59410d4 && git checkout 6f9ad22003ac11f3c7cc4819824d28f5b59410d4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e735b79ea52bfe41a04afd62e5490c60fbad757a 9fb84ac128d303c0e23da06e8376802ff02520cc && git checkout -B drift-repro e735b79ea52bfe41a04afd62e5490c60fbad757a && git merge --no-ff 9fb84ac128d303c0e23da06e8376802ff02520cc
node scripts/docs-audit/affected-docs.mjs --json e735b79ea52bfe41a04afd62e5490c60fbad757a
|
…t a row-serving door Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Contract reviewServed-tier: Read-only shape held: the diff at this head ( ① Derived judgmentsThe one changed body is the
Net: at this head no principal class can land a row it could not land before; one class (4) loses a landing the governing text never granted. Timing (a reader's extra ② Semver level
③ Boundary flags
④ Design judgmentNo face asks it; recorded as the ruling-asked judgment of the fork against the text it implements. Ruling 5985885287 A: "a row the caller cannot read answers exactly what a nonexistent id answers" — on the upsert door a nonexistent id answers a create, and a create naming a held id has only the collision to give; the 2026-11-01 amendment states that reconciliation ("a create naming a held id has no 'nonexistent' answer to give, because the key it names is taken") and binds the answer to be the create's, with no detail of its own. The fork meets it: same Implemented-by: VERDICT: PASS Must-fixes: none. Should-fixes: (1) PR body "Acceptance notes" — replace "was never written by this door" with the ① 4 reading (the arm is now unreachable for the edit-without-read class; before this diff that class could land where no write-class row filter bound); (2) pin the ① 4 class in the dogfood Reading time 2026-11-01T17:58Z. |
|
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk ACCEPT → head 维护者速读
席位意见:收。本 PR 落实维护者 2026-11-01 对 ② 的裁定(a,原话「同意」),与 #28694 合入的 ADR-0120 修订注记一致。达档复核 6561724481 为 PASS,无 must-fix。 席位核对
Generated by Claude Code |
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk
Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under
skills/**teaches how the batchupsertchooses between update and create (agit grep -i upsertofskills/names the import and seed upserts and the generated-route table only), so no skill line changes.维护者速读
POST /data/:object/batch的upsert(protocol 的batchData)里带id的行,改为按调用方能读到什么来选「更新」还是「新建」。调用方读得到的记录照旧走更新,仍由引擎判写权限;读不到的 id 按新建处理,答复与同 id 的 create 完全一致。只改protocol-batch-data.ts这一处分叉。packages/metadata-protocol/src/protocol-batch-data.ts的case 'upsert'分叉(风险 hunk:objectReadable与调用方findOne两行)和新函数callerMayReadObject;其次protocol.upsert-existence.test.ts。pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1 test/upsert-by-id-caller-read.dogfood.test.ts,预期 28/28 通过(单租户 showcase 19 个,两组织隔离启动 9 个)。What changes
batchData's by-idupsert(packages/metadata-protocol/src/protocol-batch-data.ts, thecase 'upsert'fork ofrunBatchDataLoop) now chooses its arm by what the caller can read, under the context the arm's write runs with:securityservice'scanReadObject(the engine middleware's own read gate,callerMayReadObject); a context-less call, or a host whose security service lacks the method, is not asked, and the row read below still enforces there;findOneby id, where RLS, sharing and the organization wall apply. Inside the atomic arm that context carries the transaction.A row the caller can read keeps the update arm, where
engine.updatestill judges the write. Any other id takes the create arm, the sameengine.insertcall and row builder thatcase 'create'uses, so the row answers exactly as a create naming that id answers on the same door. No new code, field, detail or sentence; nothing names another organization. There is still no fallback insert after an update failure.The fork's code comment records why this reverses the earlier existence fork, citing ruling 5985885287 (#21771) and the 2026-11-01 maintainer ruling that extends ruling 5934879010 (#21185, rule 2).
BatchDataHosttradesprobeRecord(no batch body reads it any more) forgetServicesRegistry; the OCC path andprobeRecord's own readers are untouched. The read's options are typed (EngineQueryOptionsParsed), not erased.Tests
packages/metadata-protocol/src/protocol.upsert-existence.test.ts, rewritten (11 cases): an unreadable row takes the insert arm and its row refusal deep-equals a batch create's for the same id, store untouched; the fork reads with the caller's context, never the system's; an object the security service refuses sends every row to the insert arm without a row read, asked once per loop; an admitted object still forks on the row read; a throwing admission fails the row and writes nothing; a context-less call is not asked; no fallback insert after an update failure; missing id inserts; readable row updates with the caller's context; atomic rollback with the causal row's create refusal and both reads on the transaction; mixed batch.packages/qa/dogfood/test/upsert-by-id-caller-read.dogfood.test.ts, new (28 cases), at the HTTP door, atomic and non-atomic. Stock showcase: create without edit on a private row, create and edit on a private row, a create-only principal with no read; each answers the batch create's body for the same id (rowidecho aside), with the plain create's409 UNIQUE_VIOLATIONcode and status, row byte-identical, and an unknown id is created. A readable row with edit is updated; a readable row without edit keeps the update arm's refusal; a refused-both control answers one refusal for an existing and an unknown id. Walled boot (multiTenant: true,isolated, invitation-only, two organizations created over/auth/organization/create): an editor and a creator in organization B on organization A's row get the create's answer, which names neither organization A's id, name nor slug; the editor updates its own organization's row and the creator gets the update arm's refusal.protocol.readonly-insert.test.ts: one comment names the fork's read.Acceptance notes
no: the accept set does not move. A row the caller can read takes the same arm as before. A row it cannot read never reaches the update arm: it takes the create arm and answers as a create naming that id. Only the refusal that row receives changes, to the create's (the precedent is the order change that landed asnoin fix(metadata-protocol): an update by id answers the same refusal whether or not the id exists, as delete does #28615). No(narrowing)arm was found.driver-memory, which declares primary-key uniqueness out of its scope, a create naming an existing id lands a second row; the upsert's create arm now does what the create does there. The fork's comment states it. No store enforcing the key (SQL, sqlite-wasm, Turso) is affected.Verification
Readings below were taken at
adf4e0cb4, beforeorigin/mainwas merged (12dd59a7e, delta still exactly these six files). The merged head's run (closure rebuild, package suite, focused pins, one-shot ablation with restore proof,check:type-check-debt, the gate union and the full dogfood battery) is in flight under the verify lock; its readings are below (written by the seat).Final-head readings. The run started at
12dd59a7e(merge oforigin/main) and ended at9fb84ac12, whose only addition is apackages/corepin file. Every step ran under the verify lock and reported VERDICT command-exit 0.upsert-by-id-caller-read.dogfood.test.ts: 28/28 (stock showcase 19; walled two-org 9).protocol.upsert-existence.test.ts: 11/11.check:type-check-debt: exit 0.pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1: Test Files 372 passed | 3 skipped (375), Tests 3022 passed | 28 skipped (3050).--absentexit 0).9fb84ac12: 67 derived, 67 run, 0 NOT-MEASURED.9fb84ac12: 55 success, 9 skipped, 0 failure. The earlierTest Core (4/8)red at12dd59a7ecame from the core row-serving-door pin, which did not yet classify the newcanReadObjectcaller;9fb84ac12fixes it.Touched packages: tests and typecheck at adf4e0c (verify lock VERDICT command-exit 0)
Gates: dispatch-gates.mjs --commands (65, identical at 12dd59a)
Generated by Claude Code